Skip to content
created by Aha00aAha00a at 2026-08-09
last modified by Aha00aAha00a at 2026-08-10
revision: 3

Dev ApiResponse

컨트롤러가 JSON 응답과 권한 거절 응답을 만드는 방식을 공용 트레이트로 모았다.

1. 문제

같은 사실이 여러 파일에 흩어져 적혀 있었다.

중복된 것

사본 수

위치

def Ok(json: io.circe.Json)

4

Api, ApiV1, ApiCrawler, Wiki

isAdmin

4

Api, Admin, ApiCrawler, Wiki

isSiteAdmin

2

Api, Admin

Forbidden("Access denied.")

36

Api 28, Admin 5, ApiCrawler 3

에러 봉투 {"error": ...}

66

Api 43, ApiV1 23

site not found: $seq 404

13

Api

권한 확인 → site 조회 → 404 사다리

12

Api

에러 봉투가 특히 나빴다. 같은 {"error": "..."} 를 두 관용구로 적고 있었다. Json.obj("error" -> Json.fromString(msg))Map("error" -> msg).asJson 은 결과 바이트가 같지만 표기가 달라서, 둘을 맞춰 둘 장치가 없다. 봉투를 바꿔야 할 때 한쪽만 고쳐도 컴파일이 통과한다.

2. 공용 코드

2.1. JsonResults

app/controllers/JsonResults.scala. JSON 응답을 만드는 컨트롤러가 상속한다.

  • Ok(json: Json) — circe Jsonapplication/json 으로 내보낸다.
  • JsonResult(status: Status, json: Json)Ok 이외의 상태 코드용.
  • JsonError(status: Status, message: String) — 에러 봉투를 만드는 유일한 자리.

2.2. AdminAuth

app/controllers/AdminAuth.scala. 관리자 권한 판정과 거절 응답을 함께 둔다. 둘은 함께 바뀌기 때문이다.

  • isAdmin / isSiteAdmin(siteSeq)AdminLogic 위임.
  • AccessDenied — 거절 응답.

Database 를 추상 멤버가 아니라 implicit 파라미터로 받는다. 컨트롤러의 databaseval 이 아닌 implicit 생성자 파라미터라서 추상 멤버를 구현하지 못한다. 구현하게 하려면 클래스마다 val 을 붙여 공개 접근자를 늘려야 하는데, 그 대가로 얻는 것이 없다.

AccessDeniedval 이 아니라 def 다. 트레이트의 val 은 초기화 순서 경쟁에 걸려도 컴파일은 통과하고 생성 시점 NPE 로만 드러난다. Result 생성 비용은 그 위험을 질 만큼이 아니다.

2.3. withSiteAdmin / withAdminSite

Api 안의 private 헬퍼다. "권한을 확인하고, site 를 읽고, 없으면 404" 순서를 한 곳에 둔다.

private def withSiteAdmin(seq: Long)(block: Site => Result)(implicit request: RequestHeader): Result =
  if (!isSiteAdmin(seq)) AccessDenied
  else SiteLogic.get(seq)(database).fold(siteNotFound(seq))(block)

Api 밖으로 올리지 않았다. 호출부가 전부 Api 안에 있기 때문이다. 필요한 것보다 멀리 올리는 것은 그 자체로 결합이다.

3. 통일하지 않고 남긴 것

이번 변경은 응답 바이트를 바꾸지 않는다. 아래 불일치는 **의도적으로 남겼다.** 클라이언트가 무엇을 파싱하는지 확인하지 않은 채 봉투를 바꾸면 조용히 깨진다.

  • ApiCrawler{"error": ...} 가 아니라 {"message": ...} 를 쓴다.
  • Api.pageRevision 의 404 는 {"success": false, "message": ...} 다. 세 번째 봉투다.
  • AccessDenied 는 JSON 이 아니라 text/plain 이다. 나머지 에러 응답과 Content-Type 이 다르다.

하나라도 통일하려면 관리자 UI(app/assets/admin)와 위키 스크립트가 이 응답들을 어떻게 읽는지 먼저 확인해야 한다.

3.1. 1차 정리에서 놓친 것

.toString()).as(JSON) 로 다시 훑어 손으로 봉투를 만드는 자리를 더 찾았다. 패턴으로 치환할 때는 치환 대상 목록 자체를 의심해야 한다는 뜻이다.

  • Api.adminGenerateSignedReadUrl — 트레이트에 Ok(json) 이 있는데 같은 식을 손으로 적고 있었다.
  • ApiV1 의 revision 충돌 응답 3벌 — revisionConflict 로 뽑았다. latestRevision 을 함께 실어야 해서 JsonError 로는 표현되지 않고 JsonResult 를 쓴다.
  • Api.pageRevision 의 404 — 봉투 모양은 호출부를 모르므로 그대로 두고 JsonResult 만 태웠다.

4. 목록 봉투

페이지네이션 목록은 {"array": [...], "page": N, "pageSize": N, "count": N} 하나로 답한다. JsonResults.pagedJson 이 만드는 유일한 자리다.

네 endpoint 가 이걸 손으로 만들고 있었고 **이미 갈라져 있었다** — 셋은 page·pageSize 를 보내고 ApiCrawlerarray·count 만 보냈다. 관리자 UI 가 둘 다 받아주게 짜여 있어서 아무도 몰랐다. 네 번째 endpoint 로 페이징을 시작하는 클라이언트가 있었다면 필요한 필드가 없다는 걸 그때 발견했을 것이다.

푸는 쪽도 하나다. app/assets/js/admin/api.jsunwrapPagedpagedParams 가 각각 응답 해체와 질의 파라미터 조립을 맡는다. hook 다섯 곳이 각자 풀고 있었다.

5. 에러 봉투는 compact 가 아니다

circe 의 Json.toString 은 compact 가 아니라 spaces2 로 찍는다. 실제 바이트는 아래와 같다.

{
  "error" : "site not found: 999"
}

리팩터링 전 두 관용구가 모두 Json.toString 을 거쳤으므로 이 형태였고, JsonError 도 같다. compact 라고 넘겨짚고 클라이언트에서 문자열을 비교하면 어긋난다.

6. 검증 결과

  • sbt compile 성공
  • sbt test 성공 — 기존 테스트를 하나도 바꾸지 않았다
  • 중복 표기 137곳을 공용 헬퍼 호출로 치환
  • 기존 컨트롤러 5개 순 -80줄, 새 트레이트 47줄을 더하면 순 -33줄

응답 바이트는 일회성 스펙으로 실제 앱을 띄워 라우터를 통과시켜 확인한 뒤, 앱을 소켓까지 띄우고 curl 로 다시 확인했다.

요청

응답

GET /api/Admin/Sites

403 text/plain Access denied.

GET /Admin/Sites

403 text/plain Access denied.

GET /api/Admin/CrawlerCache

403 text/plain Access denied.

GET /api/Admin/Site/999/Admins

403 — site 조회보다 권한 확인이 먼저

GET /api/v1/pages

401 application/json {"error" : ...}

GET /api/crawler?q=http://127.0.0.1/

403 application/json {"message" : ...}

GET /api/csrf

200 application/json

GET /w/FrontPage

200 text/html, 실제 페이지 렌더링

모르는 site 를 물어도 외부인에게는 404 가 아니라 403 이 간다. 권한 확인이 먼저라, 응답이 site 의 존재 여부를 알려주지 않는다.

withAdminSite 로 접은 endpoint 들의 상태 코드는 기존 ApiSiteAdminSpec 이 계속 지킨다. 다만 봉투의 본문까지 보는 검사는 없으므로, 봉투를 바꾸는 변경을 할 때는 위 값을 기준으로 직접 확인해야 한다.

7. 로컬 실행

로컬 실행은 conf/application.local.dev.conf 로 한다. 이 저장소에 없는 로컬 파일이다. 띄우기 전에 알아 둘 것이 둘 있다.

  • 클래스패스의 캐시 구현은 play-redis 뿐이다. caffeine 도 ehcache 도 없어서 Redis 없이는 서비스되지 않는다. Redis 모듈을 아예 켜지 않은 설정으로 띄우면 Guice 가 SyncCacheApi 바인딩을 찾지 못해 부팅 자체가 실패하고, 모듈은 켰는데 Redis 에 닿지 못하면 부팅은 되지만 매 요청이 500 이 된다.
  • base.confplay.evolutions.db.default.autoApply = true 가 여기에도 적용된다. 뭔가 확인하려고 띄우는 것이라면 꺼서 공유 DB 스키마를 건드리지 않게 한다.
sbt -Dconfig.file=conf/application.local.dev.conf \
    -Dplay.evolutions.db.default.autoApply=false \
    -Dhttp.port=9123 run

설정된 Redis 에 닿지 못하면 그 부분만 갈아끼우면 된다. 나머지는 설정이 가리키는 곳을 그대로 쓴다.

docker run -d --name ahawiki-local-redis -p 16379:6379 redis:7-alpine
sbt -Dconfig.file=conf/application.local.dev.conf \
    -Dplay.evolutions.db.default.autoApply=false \
    -Dplay.cache.redis.host=localhost -Dplay.cache.redis.port=16379 \
    -Dhttp.port=9123 run

7.1. Host 헤더가 site 를 고른다

SiteLogic.get(host) 가 요청 host 로 site 를 찾고, 못 찾으면 Site.notFound 로 떨어진다. 127.0.0.1:9123 으로 직접 부르면 페이지 목록이 빈 배열로 나오는데, 고장이 아니라 그 host 에 걸린 site 가 없다는 뜻이다. 실제 데이터를 보려면 실제 site 의 host 를 실어야 한다.

curl -H "Host: your.wiki.host" http://127.0.0.1:9123/api/pageNames

8. See Also

8.2. Similar Pages

Similar pages by cosine similarity. Words after page name are term frequency.

  • 51.30% Dev ApiControllers api(33:18), admin(26:10), site(29:6), json(32:1), 아니라(7:2), endpoint(3:5), wiki(3:5), with(6:2), 페이지(2:6), conf(7:1)
  • 49.56% Dev AdminUIRoleMenu site(29:12), admin(26:13), api(33:5), get(10:1), seq(6:4), access(8:2), crawler(7:1), dev(5:1), cache(4:2), 실제(5:1)
  • 49.12% Dev SiteAdmin site(29:19), admin(26:16), api(33:1), seq(6:4), sbt(4:2), dev(5:1), logic(3:2), 결과(3:2), 성공(2:3), spec(1:3)
  • 42.09% Dev Api api(33:129), admin(26:15), json(32:1), page(6:27), wiki(3:17), get(10:9), v1(4:13), seq(6:8), name(1:12), revision(5:8)
  • 40.56% Api api(33:85), json(32:5), page(6:29), admin(26:3), revision(5:18), 페이지(2:19), name(1:18), v1(4:15), wiki(3:15), get(10:5)
  • 35.54% ToDo ApiKey api(33:29), page(6:8), revision(5:7), get(10:1), conf(7:1), 403(6:1), 권한(5:2), local(4:3), 실제(5:1), text(5:1)
  • 31.29% Dev SisterWiki site(29:47), admin(26:1), page(6:13), wiki(3:9), 같은(3:8), 기존(3:7), pages(1:9), host(9:1), name(1:7), cache(4:4)

8.3. Adjacent Pages

Control
≤ 32
all
1.0x
1.0x
80
-120
ON
Metrics
Nodes(visible/total)0/0
Links(visible/total)0/0
Avg degree0.00
Depth coverage0
Queue(fetch/graph)0 / 0
Zoom(scale)1.00x
Ctrl/⌘ + Scroll: Zoom
Root 1-hop 2-hop+